استكشف أساسيات أشجار البحث الثنائية (BSTs) وتعلم كيفية تنفيذها بكفاءة في جافاسكريبت. يغطي هذا الدليل بنية وعمليات BST وأمثلة عملية للمطورين في جميع أنحاء العالم.
أشجار البحث الثنائية: دليل التنفيذ الشامل في جافاسكريبت
أشجار البحث الثنائية (BSTs) هي بنية بيانات أساسية في علوم الحاسب، وتستخدم على نطاق واسع للبحث الفعال عن البيانات وفرزها واسترجاعها. يسمح هيكلها الهرمي بتعقيد زمني لوغاريتمي في العديد من العمليات، مما يجعلها أداة قوية لإدارة مجموعات البيانات الكبيرة. يقدم هذا الدليل نظرة شاملة على أشجار البحث الثنائية ويوضح تنفيذها في جافاسكريبت، وهو موجه للمطورين في جميع أنحاء العالم.
فهم أشجار البحث الثنائية
ما هي شجرة البحث الثنائية؟
شجرة البحث الثنائية هي بنية بيانات قائمة على الشجرة حيث يكون لكل عقدة طفلان على الأكثر، يشار إليهما بالطفل الأيسر والطفل الأيمن. الخاصية الرئيسية لشجرة البحث الثنائية هي أنه لأي عقدة معينة:
- جميع العقد في الشجرة الفرعية اليسرى لها مفاتيح أقل من مفتاح العقدة.
- جميع العقد في الشجرة الفرعية اليمنى لها مفاتيح أكبر من مفتاح العقدة.
تضمن هذه الخاصية أن العناصر في شجرة البحث الثنائية مرتبة دائمًا، مما يتيح البحث والاسترجاع بكفاءة.
المفاهيم الأساسية
- العقدة (Node): وحدة أساسية في الشجرة، تحتوي على مفتاح (البيانات) ومؤشرات إلى أطفالها الأيسر والأيمن.
- الجذر (Root): العقدة العلوية في الشجرة.
- الورقة (Leaf): عقدة بدون أطفال.
- الشجرة الفرعية (Subtree): جزء من الشجرة يتجذر في عقدة معينة.
- الارتفاع (Height): طول أطول مسار من الجذر إلى ورقة.
- العمق (Depth): طول المسار من الجذر إلى عقدة معينة.
تنفيذ شجرة بحث ثنائية في جافاسكريبت
تعريف فئة العقدة (Node Class)
أولاً، نحدد فئة `Node` لتمثيل كل عقدة في شجرة البحث الثنائية. ستحتوي كل عقدة على `key` لتخزين البيانات ومؤشري `left` و `right` إلى أطفالها.
class Node {
constructor(key) {
this.key = key;
this.left = null;
this.right = null;
}
}
تعريف فئة شجرة البحث الثنائية (Binary Search Tree Class)
بعد ذلك، نحدد فئة `BinarySearchTree`. ستحتوي هذه الفئة على العقدة الجذرية وطرق لإدراج العقد والبحث عنها وحذفها واجتياز الشجرة.
class BinarySearchTree {
constructor() {
this.root = null;
}
// Methods will be added here
}
الإدراج (Insertion)
تضيف طريقة `insert` عقدة جديدة بالمفتاح المحدد إلى شجرة البحث الثنائية. تحافظ عملية الإدراج على خاصية BST عن طريق وضع العقدة الجديدة في الموضع المناسب بالنسبة للعقد الموجودة.
insert(key) {
const newNode = new Node(key);
if (this.root === null) {
this.root = newNode;
} else {
this.insertNode(this.root, newNode);
}
}
insertNode(node, newNode) {
if (newNode.key < node.key) {
if (node.left === null) {
node.left = newNode;
} else {
this.insertNode(node.left, newNode);
}
} else {
if (node.right === null) {
node.right = newNode;
} else {
this.insertNode(node.right, newNode);
}
}
}
مثال: إدراج قيم في شجرة البحث الثنائية
const bst = new BinarySearchTree();
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);
البحث (Searching)
تتحقق طريقة `search` مما إذا كانت هناك عقدة بالمفتاح المحدد موجودة في شجرة البحث الثنائية. إنها تجتاز الشجرة، وتقارن المفتاح بمفتاح العقدة الحالية وتنتقل إلى الشجرة الفرعية اليسرى أو اليمنى وفقًا لذلك.
search(key) {
return this.searchNode(this.root, key);
}
searchNode(node, key) {
if (node === null) {
return false;
}
if (key < node.key) {
return this.searchNode(node.left, key);
} else if (key > node.key) {
return this.searchNode(node.right, key);
} else {
return true;
}
}
مثال: البحث عن قيمة في شجرة البحث الثنائية
console.log(bst.search(9)); // Output: true
console.log(bst.search(2)); // Output: false
الحذف (Deletion)
تحذف طريقة `remove` عقدة بالمفتاح المحدد من شجرة البحث الثنائية. هذه هي العملية الأكثر تعقيدًا لأنها تحتاج إلى الحفاظ على خاصية BST أثناء إزالة العقدة. هناك ثلاث حالات يجب مراعاتها:
- الحالة 1: العقدة المراد حذفها هي عقدة ورقية. ببساطة قم بإزالتها.
- الحالة 2: العقدة المراد حذفها لها طفل واحد. استبدل العقدة بطفلها.
- الحالة 3: العقدة المراد حذفها لها طفلان. ابحث عن الخلف المباشر (أصغر عقدة في الشجرة الفرعية اليمنى)، واستبدل العقدة بالخلف، ثم احذف الخلف.
remove(key) {
this.root = this.removeNode(this.root, key);
}
removeNode(node, key) {
if (node === null) {
return null;
}
if (key < node.key) {
node.left = this.removeNode(node.left, key);
return node;
} else if (key > node.key) {
node.right = this.removeNode(node.right, key);
return node;
} else {
// key is equal to node.key
// case 1 - a leaf node
if (node.left === null && node.right === null) {
node = null;
return node;
}
// case 2 - node has only 1 child
if (node.left === null) {
node = node.right;
return node;
} else if (node.right === null) {
node = node.left;
return node;
}
// case 3 - node has 2 children
const aux = this.findMinNode(node.right);
node.key = aux.key;
node.right = this.removeNode(node.right, aux.key);
return node;
}
}
findMinNode(node) {
let current = node;
while (current != null && current.left != null) {
current = current.left;
}
return current;
}
مثال: إزالة قيمة من شجرة البحث الثنائية
bst.remove(7);
console.log(bst.search(7)); // Output: false
اجتياز الشجرة (Tree Traversal)
يتضمن اجتياز الشجرة زيارة كل عقدة في الشجرة بترتيب معين. هناك العديد من طرق الاجتياز الشائعة:
- الترتيب الداخلي (In-order): يزور الشجرة الفرعية اليسرى، ثم العقدة، ثم الشجرة الفرعية اليمنى. ينتج عن هذا زيارة العقد بترتيب تصاعدي.
- الترتيب المسبق (Pre-order): يزور العقدة، ثم الشجرة الفرعية اليسرى، ثم الشجرة الفرعية اليمنى.
- الترتيب اللاحق (Post-order): يزور الشجرة الفرعية اليسرى، ثم الشجرة الفرعية اليمنى، ثم العقدة.
inOrderTraverse(callback) {
this.inOrderTraverseNode(this.root, callback);
}
inOrderTraverseNode(node, callback) {
if (node !== null) {
this.inOrderTraverseNode(node.left, callback);
callback(node.key);
this.inOrderTraverseNode(node.right, callback);
}
}
preOrderTraverse(callback) {
this.preOrderTraverseNode(this.root, callback);
}
preOrderTraverseNode(node, callback) {
if (node !== null) {
callback(node.key);
this.preOrderTraverseNode(node.left, callback);
this.preOrderTraverseNode(node.right, callback);
}
}
postOrderTraverse(callback) {
this.postOrderTraverseNode(this.root, callback);
}
postOrderTraverseNode(node, callback) {
if (node !== null) {
this.postOrderTraverseNode(node.left, callback);
this.postOrderTraverseNode(node.right, callback);
callback(node.key);
}
}
مثال: اجتياز شجرة البحث الثنائية
const printNode = (value) => console.log(value);
bst.inOrderTraverse(printNode); // Output: 3 5 8 9 10 11 12 13 14 15 18 20 25
bst.preOrderTraverse(printNode); // Output: 11 5 3 9 8 10 15 13 12 14 20 18 25
bst.postOrderTraverse(printNode); // Output: 3 8 10 9 12 14 13 18 25 20 15 11
القيم الدنيا والقصوى
يعد العثور على الحد الأدنى والحد الأقصى للقيم في شجرة البحث الثنائية أمرًا بسيطًا، بفضل طبيعتها المرتبة.
min() {
return this.minNode(this.root);
}
minNode(node) {
let current = node;
while (current !== null && current.left !== null) {
current = current.left;
}
return current;
}
max() {
return this.maxNode(this.root);
}
maxNode(node) {
let current = node;
while (current !== null && current.right !== null) {
current = current.right;
}
return current;
}
مثال: إيجاد القيم الدنيا والقصوى
console.log(bst.min().key); // Output: 3
console.log(bst.max().key); // Output: 25
التطبيقات العملية لأشجار البحث الثنائية
تستخدم أشجار البحث الثنائية في مجموعة متنوعة من التطبيقات، بما في ذلك:
- قواعد البيانات: فهرسة البيانات والبحث فيها. على سبيل المثال، تستخدم العديد من أنظمة قواعد البيانات متغيرات من أشجار البحث الثنائية، مثل أشجار B، لتحديد موقع السجلات بكفاءة. ضع في اعتبارك النطاق العالمي لقواعد البيانات التي تستخدمها الشركات متعددة الجنسيات؛ يعد استرجاع البيانات بكفاءة أمرًا بالغ الأهمية.
- المترجمات (Compilers): جداول الرموز، التي تخزن معلومات حول المتغيرات والدوال.
- أنظمة التشغيل: جدولة العمليات وإدارة الذاكرة.
- محركات البحث: فهرسة صفحات الويب وتصنيف نتائج البحث.
- أنظمة الملفات: تنظيم الملفات والوصول إليها. تخيل نظام ملفات على خادم يستخدم عالميًا لاستضافة مواقع الويب؛ يساعد الهيكل المنظم جيدًا القائم على BST في تقديم المحتوى بسرعة.
اعتبارات الأداء
يعتمد أداء شجرة البحث الثنائية على هيكلها. في أفضل الحالات، تسمح شجرة البحث الثنائية المتوازنة بتعقيد زمني لوغاريتمي لعمليات الإدراج والبحث والحذف. ومع ذلك، في أسوأ الحالات (على سبيل المثال، شجرة منحرفة)، يمكن أن يتدهور التعقيد الزمني إلى وقت خطي.
الأشجار المتوازنة مقابل الأشجار غير المتوازنة
شجرة البحث الثنائية المتوازنة هي التي يختلف فيها ارتفاع الشجرتين الفرعيتين اليسرى واليمنى لكل عقدة بواحد على الأكثر. تضمن خوارزميات الموازنة الذاتية، مثل أشجار AVL وأشجار الأحمر-الأسود، أن تظل الشجرة متوازنة، مما يوفر أداءً ثابتًا. قد تتطلب المناطق المختلفة مستويات تحسين مختلفة بناءً على الحمل على الخادم؛ تساعد الموازنة في الحفاظ على الأداء في ظل الاستخدام العالمي المرتفع.
التعقيد الزمني (Time Complexity)
- الإدراج: O(log n) في المتوسط، O(n) في أسوأ الحالات.
- البحث: O(log n) في المتوسط، O(n) في أسوأ الحالات.
- الحذف: O(log n) في المتوسط، O(n) في أسوأ الحالات.
- الاجتياز: O(n)، حيث n هو عدد العقد في الشجرة.
مفاهيم متقدمة في أشجار البحث الثنائية
الأشجار ذاتية التوازن
الأشجار ذاتية التوازن هي أشجار بحث ثنائية تقوم تلقائيًا بضبط هيكلها للحفاظ على التوازن. هذا يضمن أن ارتفاع الشجرة يظل لوغاريتميًا، مما يوفر أداءً ثابتًا لجميع العمليات. تشمل الأشجار ذاتية التوازن الشائعة أشجار AVL وأشجار الأحمر-الأسود.
أشجار AVL
تحافظ أشجار AVL على التوازن من خلال ضمان أن فرق الارتفاع بين الشجرتين الفرعيتين اليسرى واليمنى لأي عقدة هو واحد على الأكثر. عندما يتعطل هذا التوازن، يتم إجراء عمليات دوران لاستعادة التوازن.
أشجار الأحمر-الأسود
تستخدم أشجار الأحمر-الأسود خصائص اللون (أحمر أو أسود) للحفاظ على التوازن. إنها أكثر تعقيدًا من أشجار AVL ولكنها تقدم أداءً أفضل في سيناريوهات معينة.
مثال كود جافاسكريبت: تنفيذ كامل لشجرة البحث الثنائية
class Node {
constructor(key) {
this.key = key;
this.left = null;
this.right = null;
}
}
class BinarySearchTree {
constructor() {
this.root = null;
}
insert(key) {
const newNode = new Node(key);
if (this.root === null) {
this.root = newNode;
} else {
this.insertNode(this.root, newNode);
}
}
insertNode(node, newNode) {
if (newNode.key < node.key) {
if (node.left === null) {
node.left = newNode;
} else {
this.insertNode(node.left, newNode);
}
} else {
if (node.right === null) {
node.right = newNode;
} else {
this.insertNode(node.right, newNode);
}
}
}
search(key) {
return this.searchNode(this.root, key);
}
searchNode(node, key) {
if (node === null) {
return false;
}
if (key < node.key) {
return this.searchNode(node.left, key);
} else if (key > node.key) {
return this.searchNode(node.right, key);
} else {
return true;
}
}
remove(key) {
this.root = this.removeNode(this.root, key);
}
removeNode(node, key) {
if (node === null) {
return null;
}
if (key < node.key) {
node.left = this.removeNode(node.left, key);
return node;
} else if (key > node.key) {
node.right = this.removeNode(node.right, key);
return node;
} else {
// key is equal to node.key
// case 1 - a leaf node
if (node.left === null && node.right === null) {
node = null;
return node;
}
// case 2 - node has only 1 child
if (node.left === null) {
node = node.right;
return node;
} else if (node.right === null) {
node = node.left;
return node;
}
// case 3 - node has 2 children
const aux = this.findMinNode(node.right);
node.key = aux.key;
node.right = this.removeNode(node.right, aux.key);
return node;
}
}
findMinNode(node) {
let current = node;
while (current != null && current.left != null) {
current = current.left;
}
return current;
}
min() {
return this.minNode(this.root);
}
minNode(node) {
let current = node;
while (current !== null && current.left !== null) {
current = current.left;
}
return current;
}
max() {
return this.maxNode(this.root);
}
maxNode(node) {
let current = node;
while (current !== null && current.right !== null) {
current = current.right;
}
return current;
}
inOrderTraverse(callback) {
this.inOrderTraverseNode(this.root, callback);
}
inOrderTraverseNode(node, callback) {
if (node !== null) {
this.inOrderTraverseNode(node.left, callback);
callback(node.key);
this.inOrderTraverseNode(node.right, callback);
}
}
preOrderTraverse(callback) {
this.preOrderTraverseNode(this.root, callback);
}
preOrderTraverseNode(node, callback) {
if (node !== null) {
callback(node.key);
this.preOrderTraverseNode(node.left, callback);
this.preOrderTraverseNode(node.right, callback);
}
}
postOrderTraverse(callback) {
this.postOrderTraverseNode(this.root, callback);
}
postOrderTraverseNode(node, callback) {
if (node !== null) {
this.postOrderTraverseNode(node.left, callback);
this.postOrderTraverseNode(node.right, callback);
callback(node.key);
}
}
}
// مثال على الاستخدام
const bst = new BinarySearchTree();
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);
const printNode = (value) => console.log(value);
console.log("اجتياز بالترتيب الداخلي:");
bst.inOrderTraverse(printNode);
console.log("اجتياز بالترتيب المسبق:");
bst.preOrderTraverse(printNode);
console.log("اجتياز بالترتيب اللاحق:");
bst.postOrderTraverse(printNode);
console.log("القيمة الدنيا:", bst.min().key);
console.log("القيمة القصوى:", bst.max().key);
console.log("البحث عن 9:", bst.search(9));
console.log("البحث عن 2:", bst.search(2));
bst.remove(7);
console.log("البحث عن 7 بعد الحذف:", bst.search(7));
الخاتمة
أشجار البحث الثنائية هي بنية بيانات قوية ومتعددة الاستخدامات ولها العديد من التطبيقات. قدم هذا الدليل نظرة شاملة على أشجار البحث الثنائية، حيث غطى هيكلها وعملياتها وتنفيذها في جافاسكريبت. من خلال فهم المبادئ والتقنيات التي نوقشت في هذا الدليل، يمكن للمطورين في جميع أنحاء العالم استخدام أشجار البحث الثنائية بفعالية لحل مجموعة واسعة من المشاكل في تطوير البرمجيات. من إدارة قواعد البيانات العالمية إلى تحسين خوارزميات البحث، تعد معرفة أشجار البحث الثنائية رصيدًا لا يقدر بثمن لأي مبرمج.
بينما تواصل رحلتك في علوم الحاسب، فإن استكشاف المفاهيم المتقدمة مثل الأشجار ذاتية التوازن وتطبيقاتها المختلفة سيعزز فهمك وقدراتك. استمر في الممارسة والتجربة مع سيناريوهات مختلفة لإتقان فن استخدام أشجار البحث الثنائية بفعالية.